Skip to content

Add Bigtable CDC YAML SchemaTransform - #40261

Open
Amar3tto wants to merge 10 commits into
masterfrom
bigtable-cdc-yaml
Open

Amar3tto wants to merge 10 commits into
masterfrom
bigtable-cdc-yaml

Conversation

@Amar3tto

@Amar3tto Amar3tto commented Sep 24, 2026 •

Copy link
Copy Markdown
Collaborator

Adds YAML support for reading Google Cloud Bigtable change streams.

This PR:

  • adds a Bigtable CDC SchemaTransform provider
  • converts ChangeStreamMutation records to Beam Rows
  • supports SetCell, DeleteCells, DeleteFamily, AddToCell, and MergeToCell entries
  • registers ReadFromBigtableCDC in standard_io.yaml
  • adds unit tests for configuration and mutation entry conversion

Tested with:

./gradlew :sdks:java:io:google-cloud-platform:test \
  --tests org.apache.beam.sdk.io.gcp.bigtable.changestreams.BigtableChangeStreamReadSchemaTransformProviderTest

Successful run: https://github.com/apache/beam/actions/runs/35729484761


Thank you for your contribution! Follow this checklist to help us incorporate your contribution quickly and easily:

  • Mention the appropriate issue in your description (for example: addresses #123), if applicable. This will automatically add a link to the pull request in the issue. If you would like the issue to automatically close on merging the pull request, comment fixes #<ISSUE NUMBER> instead.
  • Update CHANGES.md with noteworthy changes.
  • If this contribution is large, please file an Apache Individual Contributor License Agreement.

See the Contributor Guide for more tips on how to make review process smoother.

To check the build health, please visit https://github.com/apache/beam/blob/master/.test-infra/BUILD_STATUS.md

GitHub Actions Tests Status (on master branch)

Build python source distribution and wheels
Python tests
Java tests
Go tests

See CI.md for more information about GitHub Actions CI or the workflows README to see a list of phrases to trigger workflows.

@Amar3tto Amar3tto changed the title Bigtable CDC yaml Add Bigtable CDC YAML SchemaTransform Sep 24, 2026
@Amar3tto
Amar3tto requested a review from Abacn September 24, 2026 12:49
@Amar3tto
Amar3tto marked this pull request as ready for review October 6, 2026 08:04
@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

Reminder, please take a look at this pr: @Abacn

@damccorm

damccorm commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

Load balancing out some reviews.

R: @derrickaw

@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

Stopping reviewer notifications for this pull request: review requested by someone other than the bot, ceding control. If you'd like to restart, comment assign set of reviewers

@derrickaw

Copy link
Copy Markdown
Collaborator

R: @ahmedabu98, can you take a quick peek at this to see if anything else is missing? Thanks

@ahmedabu98 ahmedabu98 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good from a skim, just consider adding a flattened output option.

Also please add a YAML IT

.addNullableRowField("value_input", VALUE_SCHEMA)
.build();

static final Schema CHANGE_STREAM_MUTATION_SCHEMA =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This output schema looks complex, which will be noticed when using it with YAML.

In the regular BigtableReadSchemaTransformProvider, we added an option to use a flattened schema (one output per entry, instead of per mutation). Consider doing something similar here

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

flatten: true breaks row-level atomicity and loses intra-MutateRow entry ordering:

In Bigtable, a MutateRow applies a list of mutations atomically to a single row, and per the MutateRowRequest contract, entries within a MutateRow are applied in order so later entries mask earlier ones (for example, deleteRow() / deleteFamily() followed by setCell() in an atomic row overwrite).

ChangeStreamMutation intentionally groups all entries of a row mutation together to preserve both row-level atomicity and entry ordering. Splitting mutation.getEntries() into independent PCollection<Row> elements breaks row-level atomicity and—because all entries in the same ChangeStreamMutation share the exact same key, commit_timestamp, and token with no sequence index—loses intra-mutation ordering across any downstream shuffle (so a DeleteFamily followed by a SetCell in the same MutateRow can be replayed in reverse order downstream).

How to fix (two options):

  1. (Preferred) Remove flatten mode: Always output CHANGE_STREAM_MUTATION_SCHEMA (one Beam Row per ChangeStreamMutation with nested entries as ARRAY<ROW>), which preserves both row-level atomicity and entry ordering.
  2. If flatten is needed for YAML convenience: Default flatten to false (opt-in rather than opt-out), and add an .addInt32Field("entry_index") (the 0-based index within mutation.getEntries()) to FLATTENED_CHANGE_STREAM_MUTATION_SCHEMA so downstream consumers can reconstruct the intra-mutation execution order.

@ahmedabu98

Copy link
Copy Markdown
Contributor

cc @mutianf might be relevant for you

@Amar3tto

Copy link
Copy Markdown
Collaborator Author

please add a YAML IT

I tried adding a Bigtable CDC YAML IT using a temporary change-stream-enabled table and a bounded end time, but the pipeline consistently times out in the YAML Direct/FnApi integration-test harness.

Do you have a recommendation for how this integration test should be structured for a streaming CDC source? ReadFromSpannerCDC seems to have a similar setup, but I couldn't find a YAML IT for it either.

@Amar3tto
Amar3tto requested a review from ahmedabu98 October 20, 2026 09:34
@github-actions github-actions Bot added the build label Oct 20, 2026
@mutianf

mutianf commented Oct 20, 2026

Copy link
Copy Markdown
Contributor

@jackdingilian

@derrickaw

Copy link
Copy Markdown
Collaborator

LGTM for yaml, @ahmedabu98 - did you want to take one more peek? Thanks

@ahmedabu98

Copy link
Copy Markdown
Contributor

but the pipeline consistently times out in the YAML Direct/FnApi integration-test harness

Hmm why is it timing out? does this mean the end_at_timestamp config option doesn't work?

@ahmedabu98

Copy link
Copy Markdown
Contributor

waiting on author

@@ -259,6 +264,111 @@ def instance_prefix(instance):
return instance_id


@contextlib.contextmanager
def temp_bigtable_change_stream_table(project, prefix='yaml_bt_cdc_it_'):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resource leak if setup or table cleanup fails:

  1. table.create(), column_family.create(), update_table(), app_profile.create(), and row.commit() all run before the try ... finally: block (line 342). If any of those calls fail or time out after bigtable_instance.create() (line 285), finally: is never reached and the Bigtable instance is leaked.
  2. Inside finally:, bigtable_instance.delete() is in the same try block after update_table() and table.delete(). If disabling the change stream or deleting the table raises an exception, bigtable_instance.delete() is skipped.
  3. prefix='yaml_bt_cdc_it_' in the function signature is unused (instance_name = 'bt-cdc-tests' is hardcoded).

How to fix:
Move all post-instance-creation setup inside try:, and wrap bigtable_instance.delete() in its own try ... except block inside finally: so instance deletion is guaranteed even if table cleanup fails:

  table = bigtable_instance.table(table_id)
  table_name = f'projects/{project}/instances/{instance_id}/tables/{table_id}'
  try:
    table.create()
    # ... column_family, update_table, app_profile, row.commit(), yield ...
  finally:
    try:
      disable_request = bigtable_table_admin.UpdateTableRequest(
          table=table_pb.Table(name=table_name),
          update_mask=field_mask_pb2.FieldMask(paths=['change_stream_config']))
      bigtable_client.table_admin_client.update_table(
          request=disable_request).result(timeout=500)
      table.delete()
    except Exception:
      _LOGGER.warning('Failed to clean up Bigtable CDC table [%s]', table_id, exc_info=True)
    try:
      _LOGGER.info('Deleting Bigtable CDC instance [%s]', instance_id)
      bigtable_instance.delete()
    except Exception:
      _LOGGER.exception('Failed to delete Bigtable CDC instance [%s]', instance_id)

/** Used only for integration tests. Unsafe to use in production. */
@VisibleForTesting
ReadChangeStream withEndTime(Instant endTime) {
public ReadChangeStream withEndTime(Instant endTime) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do not expose withEndTime / end_at as a public user-facing option:

withEndTime is intentionally package-private and @VisibleForTesting ("Used only for integration tests. Unsafe to use in production."). Cloud Bigtable's low watermark is not guaranteed to be monotonic (it can go backwards), so terminating a change stream at endTime can lead to data loss—which is why withEndTime was purposefully disabled as a public option on BigtableIO.ReadChangeStream.

Making withEndTime public (which also triggers ErrorProne [VisibleForTesting] warnings when called from production code in another package) and exposing end_at / end_at_timestamp in the public ReadFromBigtableCDC YAML configuration exposes an unsafe API to users.

How to fix:
Revert BigtableIO.java and remove end_at_timestamp / end_at from BigtableChangeStreamReadConfiguration and standard_io.yaml.

Comment on lines +253 to +257
BigtableIO.ReadChangeStream readChangeStream =
BigtableIO.readChangeStream()
.withProjectId(configuration.getProjectId())
.withInstanceId(configuration.getInstanceId())
.withTableId(configuration.getTableId());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing metadata table and ExistingPipelineOptions configuration:

BigtableChangeStreamReadConfiguration exposes change_stream_name and app_profile_id, but omits two sets of options needed in production Bigtable CDC pipelines:

  1. existing_pipeline_options (withExistingPipelineOptions): BigtableIO.readChangeStream() defaults to ExistingPipelineOptions.FAIL_IF_EXISTS. If a user configures change_stream_name in YAML and restarts their pipeline, the job will fail on startup unless RESUME_OR_NEW (or SKIP_CLEANUP) can be configured.
  2. Metadata table configuration (withMetadataTableProjectId, withMetadataTableInstanceId, withMetadataTableTableId, withMetadataTableAppProfileId, withCreateOrUpdateMetadataTable): Production deployments commonly store the metadata table in a separate Bigtable instance or use a transactional read-write app profile separate from a read-only source table/app profile.

How to fix:
Add optional configuration fields for existing_pipeline_options and metadata_table_* to BigtableChangeStreamReadConfiguration, wire them to BigtableIO.ReadChangeStream here, and expose their mappings in standard_io.yaml.

.addNullableRowField("value_input", VALUE_SCHEMA)
.build();

static final Schema CHANGE_STREAM_MUTATION_SCHEMA =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

flatten: true breaks row-level atomicity and loses intra-MutateRow entry ordering:

In Bigtable, a MutateRow applies a list of mutations atomically to a single row, and per the MutateRowRequest contract, entries within a MutateRow are applied in order so later entries mask earlier ones (for example, deleteRow() / deleteFamily() followed by setCell() in an atomic row overwrite).

ChangeStreamMutation intentionally groups all entries of a row mutation together to preserve both row-level atomicity and entry ordering. Splitting mutation.getEntries() into independent PCollection<Row> elements breaks row-level atomicity and—because all entries in the same ChangeStreamMutation share the exact same key, commit_timestamp, and token with no sequence index—loses intra-mutation ordering across any downstream shuffle (so a DeleteFamily followed by a SetCell in the same MutateRow can be replayed in reverse order downstream).

How to fix (two options):

  1. (Preferred) Remove flatten mode: Always output CHANGE_STREAM_MUTATION_SCHEMA (one Beam Row per ChangeStreamMutation with nested entries as ARRAY<ROW>), which preserves both row-level atomicity and entry ordering.
  2. If flatten is needed for YAML convenience: Default flatten to false (opt-in rather than opt-out), and add an .addInt32Field("entry_index") (the 0-based index within mutation.getEntries()) to FLATTENED_CHANGE_STREAM_MUTATION_SCHEMA so downstream consumers can reconstruct the intra-mutation execution order.

Comment on lines +25 to +36
- pipeline:
type: chain
transforms:
- type: ReadFromBigtableCDC
config:
project: "{BT_CDC[PROJECT]}"
instance: "{BT_CDC[INSTANCE]}"
table: "{BT_CDC[TABLE]}"
app_profile: "{BT_CDC[APP_PROFILE]}"
start_at: "{BT_CDC[START_AT]}"
end_at: "{BT_CDC[END_AT]}"
flatten: true

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why bigtable_cdc.yaml times out here (and how to test this instead):

In BigtableIO.readChangeStream(), DetectNewPartitionsDoFn (DNP) and ReadChangeStreamPartitionDoFn (RCSP) are designed to run concurrently on a streaming runner: RCSP streams partitions and writes partition watermarks to the metadata table, while DNP tracks the global low watermark across all partitions in the metadata table and waits for all RCSPs to finish before terminating.

The Python YAML integration_tests.py harness runs external xlang transforms on Python's local batch FnApiRunner (fn_runner.py), which executes stages sequentially rather than concurrently—it buffers DNP's output in watermark_pending_inputs at MAX_TIMESTAMP and never starts RCSP until DNP finishes, creating a deadlock.

Since end_at shouldn't be exposed publicly on ReadFromBigtableCDC anyway (and FnApiRunner cannot run concurrent streaming SDF stages, which is also why ReadFromSpannerCDC and ReadFromPubSub don't run read pipelines in this harness), please remove bigtable_cdc.yaml / temp_bigtable_change_stream_table here and add a Java SchemaTransform integration test on a streaming runner instead (similar to SpannerChangeStreamsSchemaTransformIT / existing Bigtable CDC Java ITs).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants